fix(gateway): return ISO 8601 timestamps from threads endpoints#2599
Conversation
…dance#2594) ThreadResponse documents created_at / updated_at as ISO timestamps, matching the LangGraph Platform schema (langgraph_sdk.schema.Thread exposes them as datetime, JSON-encoded as ISO 8601). The gateway threads router was instead emitting str(time.time()) — unix-second floats — breaking frontend new Date() parsing and producing a mixed ISO/unix wire format that also corrupted the search sort order. Centralize timestamp generation in deerflow.utils.time: - now_iso() — datetime.now(UTC).isoformat() - coerce_iso(x) — heals legacy unix-timestamp strings on read so the store converges to ISO without a one-shot migration threads.py: replace 6 time.time() call sites with now_iso(); wrap all read paths and Phase-2 checkpoint metadata with coerce_iso(); _store_upsert opportunistically heals legacy created_at on update; drop unused time import. thread_runs.py: reuse now_iso() instead of a private duplicate _now_iso(), preventing future drift between the two timestamp call sites. Tests: 9 unit tests for the helper; 5 integration tests pinning the ISO contract for create/get/patch/search and the legacy-healing path on the internal store upsert. Full suite: 2144 passed, 15 skipped, 0 failed. Closes bytedance#2594
|
@fancyboi999 Please resolve the conflicts with the main. |
There was a problem hiding this comment.
Pull request overview
Fixes the Gateway /api/threads timestamp wire format so created_at / updated_at are consistently emitted as ISO 8601 strings (matching ThreadResponse docs and LangGraph Platform expectations), while preserving backward compatibility by normalizing legacy unix-second records on read.
Changes:
- Added shared timestamp helpers
now_iso()andcoerce_iso()indeerflow.utils.timeand adopted them across threads endpoints. - Updated thread store/checkpointer read paths to normalize legacy unix-second timestamps to ISO, and updated write paths to emit ISO directly (including opportunistic healing on update).
- Added focused unit + router integration tests to pin the ISO contract and mixed-format normalization behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| backend/packages/harness/deerflow/utils/time.py | Introduces now_iso() and coerce_iso() for ISO generation and legacy timestamp normalization. |
| backend/app/gateway/routers/threads.py | Switches thread endpoints from unix-second strings to ISO; normalizes legacy read paths and heals store records on update. |
| backend/packages/harness/deerflow/runtime/runs/manager.py | Reuses the shared now_iso() helper instead of a local implementation. |
| backend/tests/test_utils_time.py | Adds unit tests covering now_iso() and coerce_iso() conversion/edge cases. |
| backend/tests/test_threads_router.py | Adds end-to-end router tests verifying ISO output and mixed legacy/ISO normalization + ordering. |
| def coerce_iso(value: object) -> str: | ||
| """Best-effort coerce a stored timestamp to an ISO 8601 string. | ||
|
|
||
| Translates legacy unix-timestamp floats / strings written by older | ||
| DeerFlow versions into ISO without a one-shot migration. ISO strings | ||
| pass through unchanged; empty values become ``""``; unrecognised | ||
| values are stringified as a last resort. | ||
| """ | ||
| if value is None or value == "": | ||
| return "" | ||
| if isinstance(value, bool): | ||
| # ``bool`` is a subclass of ``int`` — treat as garbage, not 0/1. | ||
| return str(value) | ||
| if isinstance(value, (int, float)): | ||
| try: | ||
| return datetime.fromtimestamp(float(value), UTC).isoformat() | ||
| except (ValueError, OverflowError, OSError): | ||
| return str(value) | ||
| if isinstance(value, str): | ||
| if _UNIX_TIMESTAMP_PATTERN.match(value): | ||
| try: | ||
| return datetime.fromtimestamp(float(value), UTC).isoformat() | ||
| except (ValueError, OverflowError, OSError): | ||
| return value | ||
| return value | ||
| return str(value) |
There was a problem hiding this comment.
coerce_iso() currently stringifies non-str/non-numeric inputs (including datetime objects). If created_at / updated_at ever come through as datetime (e.g., from LangGraph internals or in-memory stores), str(datetime) produces a space-separated format (YYYY-MM-DD HH:MM:SS+00:00) rather than ISO-8601 with T, which can break consumers expecting strict ISO. Consider handling datetime explicitly (and normalizing to UTC if tz-naive) by returning value.astimezone(UTC).isoformat() / value.replace(tzinfo=UTC).isoformat() as appropriate.
There was a problem hiding this comment.
Addressed in ed9026f. coerce_iso now branches on datetime before the int/float check and routes through astimezone(UTC).isoformat() (or replace(tzinfo=UTC) when tz-naive), so the output always uses the T separator regardless of how an upstream component handed us the value.
Three new test cases cover the contract:
test_coerce_iso_handles_tz_aware_datetime— explicit assertion thatTis in the output and a space is not.test_coerce_iso_handles_tz_naive_datetime_as_utc— tz-naive input is treated as UTC.test_coerce_iso_normalises_non_utc_datetime_to_utc—+08:00value gets normalised so the wire format stays UTC.
Verification: uv run pytest tests/test_utils_time.py -v → 12 passed (3 new).
|
@fancyboi999, please check out the review message and resolve the conflicts with the main branch. |
Reconciles the ISO 8601 timestamp fix with main's ThreadMetaStore refactor: legacy ``_store_*`` helpers in threads.py are gone, replaced by ``MemoryThreadMetaStore`` / ``SqlThreadMetaStore`` reached via ``get_thread_store(request)``. Carries forward in the merged tree: - ``coerce_iso`` now handles ``datetime`` instances explicitly so ``str(datetime)`` (which uses a space separator) cannot leak through; tz-naive values are assumed UTC. Addresses the Copilot review note. - Router timestamp reads continue to flow through ``coerce_iso`` to heal legacy unix-second values still sitting in older stores; writes go through ``now_iso``. - ``MemoryThreadMetaStore`` now writes ``now_iso()`` and exposes ISO via ``coerce_iso`` in ``_item_to_dict`` so the in-memory backend matches the SQL backend's wire format. - New tests pin the datetime branch of ``coerce_iso`` and re-pin the end-to-end ISO contract on the new ThreadMetaStore-backed router.
|
@WillemJiang Thanks for the ping. Both items are addressed in ed9026f: 1. Copilot review (
2. Conflicts with main main refactored
Verification (The 19 errors in the full run are all in |
After the merge with main, three additional read paths in ``threads.py``
were still emitting raw ``str(metadata.get("created_at", ""))`` —
``get_thread_state``, ``update_thread_state``, and ``get_thread_history``.
Same root cause as bytedance#2594: when the checkpoint metadata's ``created_at``
is a unix-second float (legacy data, or a checkpoint written by an older
Gateway version), ``str(float)`` produces ``"1777252410.411327"`` and the
frontend's ``new Date(...)`` returns ``Invalid Date``. The fix on the
``/threads/{id}`` GET path was already in place; these three sibling
endpoints needed the same treatment.
All four call sites now flow through ``coerce_iso``, so:
- legacy float metadata heals to ISO on the way out,
- ISO metadata passes through unchanged,
- ``datetime`` instances (which the new ``coerce_iso`` branch handles
explicitly) emit with the ``T`` separator instead of falling through
to the space-separated ``str(datetime)`` form.
Coverage added for the two endpoints not already pinned by the merge:
- ``test_get_thread_state_returns_iso_for_legacy_checkpoint_metadata``
- ``test_get_thread_history_returns_iso_for_legacy_checkpoint_metadata``
Both pre-seed a checkpoint whose metadata carries the literal float
from the issue body and assert the wire format is ISO.
|
Follow-up: while scanning the merged tree I found three sibling endpoints that were still emitting raw Affected endpoints:
When the checkpoint metadata's All four call sites now flow through
Coverage added for the two endpoints that weren't already pinned:
Both pre-seed a checkpoint whose metadata carries the literal float from the issue body ( Verification: Rationale for bundling vs. separate PR: this is the same wire-format contract as #2594 — splitting would just leave three known-broken routes in the tree until a follow-up gets prioritized. Happy to split if you'd prefer to keep this PR's diff minimal. |
What
Threads endpoints under
/api/threadsreturncreated_at/updated_atas unix-second strings ("1777252410.411327") instead of the ISO 8601 format thatThreadResponsedocuments and that the LangGraph Platform schema (langgraph_sdk.schema.Thread) treats asdatetime. This PR fixes the wire format.Closes #2594.
Why
Three concrete fallouts of the mismatch:
formatTimeAgo(frontend/src/core/utils/datetime.ts) ends up callingnew Date("1777252410.411327"), which returnsInvalid Date. The chats list (frontend/src/app/workspace/chats/page.tsx:58) and the markdown export (frontend/src/core/threads/export.ts:32) render "Invalid Date" today.search_threadsreturns mixed formats from the same endpoint. Phase 1 reads the store (unix-float strings written by the gateway) while Phase 2 reads the checkpointer (ISO strings written by LangGraph Server). Theresults.sort(key=lambda r: r.updated_at, reverse=True)is a lexical string sort, so"1777..."always lands after"2026..."regardless of actual time.thread_runs.py,assistants_compat.py,agents/memory/storage.py,guardrails/middleware.py,skills/manager.py,sandbox_audit_middleware.py) already usesdatetime.now(UTC).isoformat(), andtests/test_client.py:1007asserts ISO.threads.pywas the only file producing the legacy format.How
New helper at
backend/packages/harness/deerflow/utils/time.py:now_iso()returnsdatetime.now(UTC).isoformat().coerce_iso(value)converts legacy unix-second strings/numbers to ISO; ISO strings, empty values, and unrecognised inputs pass through. The legacy match is anchored to 10 digits (^\d{10}(?:\.\d+)?$) so a 4-digit ISO year like"2026"cannot be misread as a unix timestamp, and the 10-digit shape stays valid until year 2286.The helper sits in
harness/deerflow/utils/so both the harness layer (runtime/runs/manager.py) and the app layer (app/gateway/routers/threads.py) can import it without crossing the harness→app boundary thattests/test_harness_boundary.pyenforces.Changes in
threads.py:time.time()→now_iso(). The issue lists 5;update_thread_stateat line 606 follows the same pattern, so it's included.coerce_iso():create_threadidempotent return,search_threadsPhase 1 and Phase 2,patch_thread,get_thread, and the legacy synthesis branch._store_upsertrewrites legacycreated_atto ISO on the update path, so the store converges to ISO without a migration script.import time.thread_runs.pyswaps its private_now_isofor the sharednow_isoto keep the two call sites from drifting apart later.Modules that already emit correct ISO via inline
datetime.now(UTC).isoformat()are left alone in this PR. Consolidating them into the helper is a mechanical follow-up outside the issue's scope.Testing
tests/test_utils_time.py(9 cases) coversnow_isoand thecoerce_isobranches: ISO passthrough, unix-second string and numeric forms,None, empty string, the short-numeric guard against"2026", unparseable input, and theboolsubclass-of-inttrap.tests/test_threads_router.pyadds 5 cases using reallanggraph.checkpoint.memory.InMemorySaverandlanggraph.store.memory.InMemoryStore(the components the gateway boots in dev when no checkpointer is configured):create_threadreturns ISOcreated_at/updated_at.get_threadreturns ISO for a pre-seeded legacy unix-timestamp record.patch_threadreturns ISO andupdated_atadvances pastcreated_at.search_threadsover a legacy + modern mix normalizes everything to ISO and sorts by real time._store_upsertwrites ISO on create and heals legacycreated_aton update.Live HTTP verification
The integration tests already drive the real router through FastAPI's TestClient. To rule out anything that sits above the router, I also booted
uvicorn app.gateway.app:appon port 8001 and ran the issue's exact repro against the live process.PATCH advances
updated_atcorrectly:GET /api/threads/{id}andPOST /api/threads/searchreturn ISO in the same session.To exercise the
coerce_isohealing path, I pre-seeded the live store withcreated_at = "1777252410.411327"(the literal value from the issue body) plus a separate ISO record:The healed value matches the
Expected formatexample in the issue body to the microsecond, so the unix↔ISO round-trip preserves information. The mixed-format sort now reflects real time order.The affected endpoints only touch the store and checkpointer, so no model/LLM call was involved in any of this verification.
Compatibility
ThreadResponse.created_atandupdated_atkeep thestrtype. Only the semantic format changes, no breaking schema change. Legacy unix-string store records are normalized on read, so no data migration script is required.